//===----------------------------------------------------------------------===//
// Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//

/// Parsing, comparison, and manipulation of [semantic version](https://semver.org/spec/v2.0.0.html) numbers.
@ModuleInfo { minPklVersion = "0.26.0" }
module pkl.semver

/// Tells whether [version] is a valid semantic version number.
function isValid(version: String) = parseOrNull(version) != null

/// Parses [version] as a semantic version number.
///
/// Throws if [version] is not a valid semantic version number.
///
/// Examples:
/// ```
/// semver.Version("1.2.3")
/// semver.Version("1.2.3-alpha")
/// semver.Version("1.2.3+456")
/// semver.Version("1.2.3-alpha+456")
/// ```
function Version(version: String): Version =
  parseOrNull(version) ?? throw("`\(version)` is not a valid semantic version number.")

/// Parses [version] as a semantic version number.
///
/// Returns [null] if [version] is not a valid semantic version number.
///
/// Facts:
/// ```
/// semver.parseOrNull("1.2.3") == semver.Version("1.2.3")
/// semver.parseOrNull("1.2.3-alpha+456") == semver.Version("1.2.3-alpha+456")
/// semver.parseOrNull("1") == null
/// ```
function parseOrNull(version: String): Version? =
  let (groups = versionRegex.matchEntire(version)?.groups)
    if (groups == null) null
    else
      new Version {
        major = groups[1].value.toInt()
        minor = groups[2].value.toInt()
        patch = groups[3].value.toInt()
        preRelease = groups[4]?.value
        build = groups[5]?.value
      }

/// A version comparator for use with methods such as [List.minWith()].
comparator: (Version, Version) -> Boolean = (v1: Version, v2: Version) -> v1.isLessThan(v2)

/// A [semantic version](https://semver.org/spec/v2.0.0.html).
///
/// To test if two versions are equal according to semantic versioning rules, use [equals()] instead of `==`.
class Version {
  /// Major version zero (0.y.z) is for initial development.
  /// Anything MAY change at any time.
  /// The public API SHOULD NOT be considered stable.
  ///
  /// Version 1.0.0 defines the public API.
  /// The way in which the version number is incremented after this release is dependent on this public API and how it changes.
  ///
  /// Major version X (X.y.z | X > 0) MUST be incremented if any backwards incompatible changes are introduced to the public API.
  /// It MAY also include minor and patch level changes.
  /// Patch and minor version MUST be reset to 0 when major version is incremented.
  major: UInt

  /// Minor version Y (x.Y.z | x > 0) MUST be incremented if new, backwards compatible functionality is introduced to the public API.
  /// It MUST be incremented if any public API functionality is marked as deprecated.
  /// It MAY be incremented if substantial new functionality or improvements are introduced within the private code.
  /// It MAY include patch level changes.
  /// Patch version MUST be reset to 0 when minor version is incremented.
  minor: UInt

  /// Patch version Z (x.y.Z | x > 0) MUST be incremented if only backwards compatible bug fixes are introduced.
  /// A bug fix is defined as an internal change that fixes incorrect behavior.
  patch: UInt

  /// A pre-release version MAY be denoted by appending a hyphen and a series of dot separated identifiers immediately following the patch version.
  /// Identifiers MUST comprise only ASCII alphanumerics and hyphens `[0-9A-Za-z-]`.
  /// Identifiers MUST NOT be empty. Numeric identifiers MUST NOT include leading zeroes.
  /// Pre-release versions have a lower precedence than the associated normal version.
  /// A pre-release version indicates that the version is unstable and might not satisfy the intended compatibility requirements as denoted by its associated normal version.
  ///
  /// Examples:
  /// - `"1.0.0-alpha"`
  /// - `"1.0.0-alpha.1"`
  /// - `"1.0.0-0.3.7"`
  /// - `"1.0.0-x.7.z.92"`
  /// - `"1.0.0-x-y-z.–"`
  preRelease: String(matches(Regex(#"(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*"#)))?

  hidden fixed preReleaseIdentifiers: List<Int|String> =
    if (preRelease == null) List() else preRelease.split(".").map((it) -> it.toIntOrNull() ?? it)

  /// Build metadata MAY be denoted by appending a plus sign and a series of dot separated identifiers immediately following the patch or pre-release version.
  /// Identifiers MUST comprise only ASCII alphanumerics and hyphens `[0-9A-Za-z-]`.
  /// Identifiers MUST NOT be empty.
  /// Build metadata MUST be ignored when determining version precedence.
  /// Thus two versions that differ only in the build metadata, have the same precedence.
  ///
  /// Examples:
  /// - `"1.0.0-alpha+001"
  /// - `"1.0.0+20130313144700"`
  /// - `"1.0.0-beta+exp.sha.5114f85"`
  /// - `"1.0.0+21AF26D3—-117B344092BD"`
  ///
  /// Note: Unlike `==`, [equals()] and comparison methods such as [isLessThan()] ignore [build].
  build: String(matches(Regex(#"[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*"#)))?

  hidden fixed buildIdentifiers: List<String> =
    if (build == null) List() else build.split(".")

  /// Tells whether this version is equal to [other] according to semantic versioning rules.
  ///
  /// Facts:
  /// ```
  /// semver.Version("1.0.0").equals(semver.Version("1.0.0"))
  /// !(semver.Version("1.0.0").equals(semver.Version("1.0.1")))
  /// semver.Version("1.0.0-alpha+001").equals(semver.Version("1.0.0-alpha+999"))
  /// ```
  ///
  /// Note: `version1.equals(version2)` differs from `version1 == version2` in that it ignores [build].
  function equals(other: Version): Boolean =
    major == other.major &&
      minor == other.minor &&
      patch == other.patch &&
      preRelease == other.preRelease

  /// Tells whether this version is less than [other] according to semantic versioning rules.
  ///
  /// Facts:
  /// ```
  /// semver.Version("1.0.0").isLessThan(semver.Version("2.0.0"))
  /// semver.Version("2.0.0").isLessThan(semver.Version("2.1.0"))
  /// semver.Version("2.1.0").isLessThan(semver.Version("2.1.1"))
  ///
  /// semver.Version("1.0.0-alpha").isLessThan("1.0.0")
  ///
  /// semver.Version("1.0.0-alpha").isLessThan(semver.Version("1.0.0-alpha.1"))
  /// semver.Version("1.0.0-alpha.1").isLessThan(semver.Version("1.0.0-alpha.beta"))
  /// semver.Version("1.0.0-alpha.beta").isLessThan(semver.Version("1.0.0-beta"))
  /// semver.Version("1.0.0-beta").isLessThan(semver.Version("1.0.0-beta.2"))
  /// semver.Version("1.0.0-beta.2").isLessThan(semver.Version("1.0.0-beta.11"))
  /// semver.Version("1.0.0-beta.11").isLessThan(semver.Version("1.0.0-rc.1"))
  /// semver.Version("1.0.0-rc.1").isLessThan(semver.Version("1.0.0"))
  /// ```
  function isLessThan(other: Version): Boolean =
    major < other.major ||
    minor < other.minor ||
    patch < other.patch ||
    isPreReleaseLessThan(other)

  /// Tells whether this version is less than or equal to [other] according to semantic versioning rules.
  function isLessThanOrEquals(other: Version): Boolean =
    isLessThan(other) || equals(other)

  /// Tells whether this version is greater than [other] according to semantic versioning rules.
  function isGreaterThan(other: Version): Boolean =
    other.isLessThan(this)

  /// Tells whether this version is greater than or equal to [other] according to semantic versioning rules.
  function isGreaterThanOrEquals(other: Version): Boolean =
    other.isLessThanOrEquals(this)

  /// A normal version number MUST take the form X.Y.Z where X, Y, and Z are non-negative integers, and MUST NOT contain leading zeroes.
  function isNormal(): Boolean = preRelease == null && build == null

  /// Tells if this version has a non-zero [major] and no [preRelease].
  function isStable(): Boolean = major > 0 && preRelease == null

  /// Strips [preRelease] and [build] from this version.
  function toNormal(): Version = (this) { preRelease = null; build = null }

  function toString() = "\(major).\(minor).\(patch)\(if (preRelease != null) "-\(preRelease)" else "")\(if (build != null) "+\(build)" else "")"

  local function isPreReleaseLessThan(other: Version): Boolean =
    if (preRelease == null) false
    else if (other.preRelease == null) true
    else if (preRelease == other.preRelease) false
    else
      let (result = preReleaseIdentifiers
        .zip(other.preReleaseIdentifiers)
        .fold(null, (result, next) ->
          if (result != null) result
          else
            if (next.first == next.second) null
            else if (next.first.getClass() == next.second.getClass()) next.first < next.second
            else next.first is Int
        )
      ) if (result != null) result else preReleaseIdentifiers.length < other.preReleaseIdentifiers.length
}

// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
local versionRegex = Regex(#"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?"#)
